{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<div style=\"text-align: right\" align=\"right\"><i>Peter Norvig, Sept 2022</i></div>\n",
    "\n",
    "# Anigrams Puzzle\n",
    "\n",
    "From 538's [**The Riddler** on Sept 16, 2022](https://fivethirtyeight.com/features/can-you-build-the-biggest-anigram/) comes this puzzle:\n",
    "    \n",
    "> In the game of [**Anigrams**](http://anigrams.us), you **unscramble** successively larger, nested collections of letters to create a valid **chain** of six English **words** between four and nine letters in length. For example, a **chain** of five words (sadly, less than the six needed for a valid game of Anigrams) can be constructed using the following sequence, with each **term** after the first including **one additional letter** than the previous **term**:\n",
    ">\n",
    ">- DEIR (which unscramble to make the words DIRE, IRED or RIDE)\n",
    ">- DEIR**D** (DRIED or REDID)\n",
    ">- DEIRD**L** (DIRLED, DREIDL or RIDDLE)\n",
    ">- DEIRDL**R** (RIDDLER)\n",
    ">- DEIRDLR**S** (RIDDLERS)\n",
    ">\n",
    ">\n",
    "> What is the **longest chain** of such nested anagrams you can create, **starting** with four letters?\n",
    ">\n",
    "> *Extra credit:* **How many possible games** of Anigrams are there? That is, how many valid sets are there of four initial letters, and then five more **letters added** one at a time in an ordered sequence, that result in a sequence of six valid anagrams? (Note: Swapping the order of the first four letters does not result in a distinct game.)\n",
    "\n",
    "I'll solve this puzzle with a Python program. First some generally useful imports:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "metadata": {},
   "outputs": [],
   "source": [
    "from typing import Set, List\n",
    "import functools "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Basic Concepts\n",
    "\n",
    "The puzzle description mentions a bunch of concepts: **word**, **chain**, **term**, **starting**, **unscramble**,  **longest chain**, and **one additional letter**. To solve the puzzle, I'll carefully define each concept, in prose and in Python. Once I have a collection of concepts, I can see how they fit together to solve the puzzle. First the data types:\n",
    "\n",
    "- **Word**: an English language word, e.g. `'ride'`.\n",
    "- **Chain**: a list of words, each one adding a letter, e.g. `['ride', 'redid', 'riddle']`. \n",
    "- **Term**: a scrambled word, e.g. `'deir'`. "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "metadata": {},
   "outputs": [],
   "source": [
    "Word  = str        # A word in the English word list, e.g. 'ride'.\n",
    "Chain = List[Word] # A list of words, each one adding a letter, e.g. ['ride', 'redid', 'riddle']. \n",
    "Term  = str        # A scrambled word, e.g. 'deir'"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Then some functions and constants:\n",
    "\n",
    "- **scramble**: I'll define `scramble(word)` to return one of the *n*! ways to scramble an *n*-letter word. I'll arbitrarily choose alphabetical order.\n",
    "- **terms**: I'll define `all_terms` as a dict of `{Term: Word}` pairs, e.g.  `{'deir': 'ride', 'ddeir': 'redid', ...}`.\n",
    "- **starting**: I'll define `starting_terms` to be the set of 4-letter terms from `all_terms`, e.g. `{'deir', 'acef', 'eirt', ...]`.\n",
    "- **unscramble**: I'll define `unscramble(term)` to return the corresponding word. A term might unscramble to several words; I'll arbitrarily pick one.\n",
    "- **longest chain**: I'll define`longest(chains)` to return the chain with the most words.\n",
    "- **one additional letter**: I'll define `add_one_letter(term)` to return the set of valid terms that can be formed by adding one letter to `term`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "metadata": {},
   "outputs": [],
   "source": [
    "def scramble(word: Word) -> Term: \n",
    "    \"\"\"A canonical scrambling of the letters in `word` (into alphabetical order).\"\"\"\n",
    "    return ''.join(sorted(word))\n",
    "\n",
    "all_terms = {scramble(w): w for w in open('enable1.txt').read().split()} # {Term: Word}\n",
    "\n",
    "starting_terms = {term for term in all_terms if len(term) == 4} # 4-letter terms\n",
    "\n",
    "unscramble = all_terms.get # e.g., unscramble('deir') = 'ride'\n",
    "\n",
    "def longest(chains) -> Chain: return max(chains, key=len, default=[])\n",
    "\n",
    "def add_one_letter(term: Term) -> Set[Term]:\n",
    "    \"\"\"The set of valid Terms (from `all_terms`) that can be formed by adding one letter to `term`.\"\"\"\n",
    "    terms = (scramble(term + L) for L in 'abcdefghijklmnopqrstuvwxyz')\n",
    "    return {t for t in terms if t in all_terms} "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The following assertions serve as unit tests and also as examples of usage:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "metadata": {},
   "outputs": [],
   "source": [
    "assert scramble('ride') == 'deir'\n",
    "assert unscramble('deir') == 'ride'\n",
    "assert scramble('dire') == scramble('ired') == scramble('ride')\n",
    "assert unscramble('billowy') == scramble('billowy') == 'billowy'\n",
    "assert longest(([1, 2, 3], [1, 2], [1, 2, 3, 4], [3, 2, 1])) == [1, 2, 3, 4]\n",
    "assert longest([]) == [] # The longest of no chains is the empty chain\n",
    "assert add_one_letter('abck') == {'aabck', 'abckl', 'abcks'}\n",
    "assert add_one_letter(scramble('back')) == {scramble('aback'), scramble('black'), scramble('backs')}\n",
    "assert add_one_letter(scramble('riddler')) == {scramble('riddlers')}\n",
    "assert add_one_letter(scramble('riddlers')) == set()\n",
    "assert len(all_terms) == 156473\n",
    "assert len(starting_terms) == 2674"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Solution\n",
    "\n",
    "We are now ready to compute \"the longest chain ... starting with four letters.\" First we define `longest_starting_from(term)` to compute the longest chain starting from a term, then we apply that to every `starting_term`, and take the `longest`. To compute `longest_starting_from(term)`, recursively apply `longest_starting_from` to every one-letter addition to `term`, and then return the longest of those chains, with the unscrambling of `term` prepended. "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "CPU times: user 1.35 s, sys: 9.73 ms, total: 1.36 s\n",
      "Wall time: 1.36 s\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "['nine',\n",
       " 'inane',\n",
       " 'eonian',\n",
       " 'enation',\n",
       " 'nominate',\n",
       " 'nominates',\n",
       " 'antinomies',\n",
       " 'nitrosamine',\n",
       " 'terminations',\n",
       " 'antimodernist',\n",
       " 'determinations',\n",
       " 'underestimation',\n",
       " 'underestimations']"
      ]
     },
     "execution_count": 5,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "cache = functools.lru_cache(None) # Used as a function decorator to avoid re-computation\n",
    "\n",
    "@cache\n",
    "def longest_starting_from(term: Term) -> Chain:\n",
    "    \"\"\"The longest chain starting with `term`. Equal to the unscrambling of `term`,\n",
    "    followed by the longest chain starting from a one-letter addition to `term` (if there are any).\"\"\"\n",
    "    chains = map(longest_starting_from, add_one_letter(term))\n",
    "    return [unscramble(term), *longest(chains)]\n",
    "\n",
    "%time longest(map(longest_starting_from, starting_terms)) # A solution"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We see that the longest chain gets up to a 16-letter word. There are other chains that are equally long, and it turns out that all of them end with either `indeterminations` or `underestimations`. \n",
    "\n",
    "*Note*: Typically a recursive function has the form \"if terminal_condition return something else recurse.\" The function `longest_starting_from` does not need an explicit check for a terminal condition, because when `add_one_letter(term)` returns the empty set, the function `longest` deals with it (via the `default=[]` keyword)."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Extra Credit\n",
    "\n",
    "How many possible games of Anigrams are there? A game is a chain that starts with a 4-letter word and ends with a 9-letter word. Swapping the order of letters in any word \"does not result in a distinct game.\" (I don't have to worry about that because my `unscramble(term)` always picks the same word, given a term.)\n",
    "\n",
    "The number of games starting from a given term is exactly 1 if the term has 9 letters, and is otherwise the sum of the number of games starting from each one-letter addition to term. To find the total number of possible games, just sum up the `games_starting_from` for each 4-letter term:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "metadata": {},
   "outputs": [],
   "source": [
    "@cache\n",
    "def games_starting_from(term: Term, goal_length=9) -> int:\n",
    "    \"\"\"How many games start with `term` and end with a word of `goal_length` letters?\"\"\"\n",
    "    if len(term) == goal_length:\n",
    "        return 1\n",
    "    else:\n",
    "        return sum(games_starting_from(t, goal_length) for t in add_one_letter(term))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "CPU times: user 816 ms, sys: 4.69 ms, total: 821 ms\n",
      "Wall time: 820 ms\n"
     ]
    },
    {
     "data": {
      "text/plain": [
       "4510515"
      ]
     },
     "execution_count": 7,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "%time sum(map(games_starting_from, starting_terms))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "There are 4,510,515 total possible games. \n",
    "\n",
    "## More Tests, Examples, and Notes\n",
    "\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "metadata": {},
   "outputs": [],
   "source": [
    "assert games_starting_from('beep') == 1\n",
    "assert longest_starting_from('beep') == ['beep', 'plebe', 'beleap', 'beleapt', 'bedplate', 'bedplates']\n",
    "assert max([games_starting_from(term), term] for term in starting_terms) == [64422, 'aers']\n",
    "for word in 'alerts alters artels estral laster ratels salter slater staler stelar talers'.split():\n",
    "    assert scramble(word) == 'aelrst' \n",
    "for (term, word) in all_terms.items():\n",
    "    assert scramble(word) == term\n",
    "    assert unscramble(term) == word\n",
    "    assert scramble(unscramble(term)) == term\n",
    "    assert unscramble(scramble(word)) == word"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The words `'a'` and `'I'` are not in `enable1.txt`, because one-letter words are not valid Scrabble™️ words. We could add them, and then find the longest chain of all:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/plain": [
       "['a',\n",
       " 'as',\n",
       " 'sea',\n",
       " 'teas',\n",
       " 'teams',\n",
       " 'stamen',\n",
       " 'tameins',\n",
       " 'mannites',\n",
       " 'nominates',\n",
       " 'antinomies',\n",
       " 'nitrosamine',\n",
       " 'terminations',\n",
       " 'antimodernist',\n",
       " 'determinations',\n",
       " 'underestimation',\n",
       " 'underestimations']"
      ]
     },
     "execution_count": 9,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "all_terms.update({'a': 'a', 'i': 'i'})\n",
    "longest(map(longest_starting_from, all_terms))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*Note*: The puzzle description uses the phrase \"collection of letters\" and the word \"anagram\" as synonyms for \"term\". I chose to use \"term\" throughout because \"collection of letters\" is too long, and \"anagram\" (although more descriptive than the bland \"term\") is not quite right: \"ride\" and \"dire\" are indeed anagrams of each other, but \"deir\" is not an anagram, because it is not an English word. Rather, \"deir\" is a *scrambled collection of letters that correspond to a word*. Also, I like the fact that \"term\" and \"word\" have the same number of letters.\n",
    "\n",
    "*Note*: This whole notebook only takes about 3 seconds to run. The use of the `@cache` decorator means that the complexity of the whole algorithm is *O*(*T*), where *T* is the number of terms. Without the `@cache`, many chains would have to be re-computed multiple times. For example, starting at `'ride'` you can add one letter to get to either `'riced'` or `'rimed'`. From either of those you can add one letter to get to `'dermic'`, and from there there's a whole subtree of possibilities to explore. With `@cache`, that subtree is only computed once. Without `@cache` it is computed twice, and lots of other subtrees are computed multiple times, making the overall run time about ten-fold slower.\n",
    "\n",
    "*Note*: I could have represented a chain as a list of scrambled terms, rather than a list of words, but I figure if you want the solution, you'd rather see words, not scrambles. I also could have represented a chain as an initial set of four letters plus the letters that get added, in order. For example, `'deir' + 'd' + 'l' + 'r' + 's' == 'deirdlrs'`. That would have been a more compact representation than a list, but messier to deal with. For example, the word `'dermic'` would be reached by two paths, `'deircm'` and `'deirmc'`, and we would lose the advantage of `@cache` unless we took some convoluted steps to translate back and forth among the chain format, the scrambled word format, and the unscrambled word format.\n",
    "\n",
    "*Note*: Because the order of iteration over sets is not fixed in Python, different runs of this notebook can produce different results.\n",
    "\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.7.6"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
